AWS IoT MQTT over WebSocketをSORACOM EndorseのIMSI認証でSSOする
ども、大瀧です。
スケーラブルかつ従量課金で激安なマネージドMQTTブローカーのAWS IoT、皆さん試してますか?先日MQTT over WebSocketがサポートされWebブラウザから接続できるようになりました。弊社荒井が早速試しています。
IoTデバイスだけでなくWebアプリケーションやモバイルアプリケーションでもPub/Subブローカーの仕組みとしていろいろ応用できそうですよね。ただ、端末から直接AWS IoTに接続しにいく都合で、認証情報をどうやって持たせるかが課題として挙がってきます。例えば荒井の以下の記事の、認証なしでIDを付ける例があります。
これ以外に、CognitoでFacebook認証などと組み合わせたり、Web APIで独自の認証を作り込みAWSの一時クレデンシャルを発行する方法などが考えられますが、それなりの実装が必要ですしユーザーに認証情報の入力を強いることにもなってしまいます。
そこで今回は、IoTプラットフォームサービスSORACOMの認証サービスであるEndorseを利用してSIM単位の認証を実装しつつ、AWS IoTへのシングルサインオン(SSO)を構成してみたいと思います。
概要
荒井のソースを参考に(丸○クり)した、チャットアプリになっています。
※ 今回はAjaxを利用するためにローカルファイルでは無くS3 Static Website Hostingでホストしました。
遷移図でまとめてみました。
まず最初のポイントは、1
のEndorseへのリクエストです。SORACOMでEndorseを有効化しSIMを装着したマシンからhttps://endorse.soracom.io/
にリクエストを送出すると、IMSIを含めあらかじめ設定された文字列に署名が施され、JWT形式のトークンがレスポンスとして返ってきます。SORACOM以外の経路ではアクセスできず、また署名が施されるのでIMSIの詐称が出来ない仕組みになっています。
さらにhttps://endorse.soracom.io/?redirect_url=<リダイレクト先URL>
とリクエストを送ると、レスポンスが302リダイレクトになり、Location
ヘッダに<リダイレクト先URL>?soracom_endorse_token=<Endorseトークン>
がセットされます。これを利用し、2
の処理を行うWeb APIのエンドポイント(今回はAmazon API Gateway & AWS Lambda)にリダイレクトさせてシングルサインオンっぽく動かしてみます。
2
のSIMの認証と一時クレデンシャル発行を扱うWeb APIは、AWSのマネージドサービスであるAPI Gateway + Lambdaで組んでみました。普通のHTTPSリクエスト&レスポンスが返せればなんでも良いので、EC2でもAWSでなくとも普通に動くと思います。認証の手順は、以下の記事の実装とほとんど変わりません。レスポンスにAWS IoTのWebsocketエンドポイントと署名済みAWSクレデンシャルをコミコミで返すようにしています。
では、行ってみましょう!
SORACOMの構成
SORACOMユーザーコンソールで、SIMが所属するグループのEndorse設定を有効にしましょう。項目のIMSI
のチェックがオンになっていることを確認します。また、今回はHTMLファイルからAjaxでEndorseおよびAPI Gatewayに接続するので、HTMLファイルをホストするURL(今回はAmazon S3 Static Website Hostingを利用)についてCORS(許可するオリジン
)を設定します。
リダイレクトを許可するURLも合わせて指定するので、設定から流れがイメージできる感じで良いですね。
Websocketクライアント(HTML&JavaScript)の実装
荒井のソースと比べるとAWSクレデンシャルに関する記述がごっそり無くなっていて、クライアント側で認証情報を持たないことがわかりますね。MQTTクライアントのライブラリ(mqttws31.js
)はあらかじめダウンロードし、HTMLファイルと一緒にWebサーバー(今回はAmazon S3)にアップロードしておきます。
<html lang="ja"> <body> <ul id="chat"> <li v-for="m in messages">{{ m }}</li> </ul> <input type="text" name="say" id="say" placeholder="Input a message here..."> <button id="send">Send</button> <script src="http://code.jquery.com/jquery-1.12.0.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/vue/1.0.16/vue.min.js" type="text/javascript"></script> <script src="./mqttws31.js" type="text/javascript"></script> <script type="text/javascript"> var data = { messages: [] }; new Vue({ el: '#chat', data: data }); document.getElementById('send').addEventListener('click', function (e) { var say = document.getElementById('say') send(say.value); say.value = ''; }); var endorse_url = 'https://endorse.soracom.io/?redirect_url=https://XXXXXXXX.execute-api.ap-northeast-1.amazonaws.com/prod/'; var client = null; $.get(endorse_url, function(endpoint) { console.log('Endpoint: '+ endpoint); var clientId = Math.random().toString(36).substring(7); client = new Paho.MQTT.Client(endpoint, clientId); var connectOptions = { useSSL: true, timeout: 3, mqttVersion: 4, onSuccess: subscribe }; client.connect(connectOptions); client.onMessageArrived = onMessage; client.onConnectionLost = function(e) { console.log(e) }; }); function subscribe() { client.subscribe("Test/chat"); console.log("subscribed"); } function send(content) { var message = new Paho.MQTT.Message(content); message.destinationName = "Test/chat"; client.send(message); console.log("sent"); } function onMessage(message) { data.messages.push(message.payloadString); console.log("message received: " + message.payloadString); } </script> </body> </html>
- 28行目 : 上述のSORACOM EndorseのURLとリダイレクト先としてAPI GatewayのURLをセットします。API Gatewayは
/
リソースへのGET
メソッド、prod
ステージでデプロイしました - 30行目 :
jQuery.get()
はリダイレクトに追随するので、引数endpoint
にAPI Gatewayのレスポンス(以下のようなAWS IoT Websocketエンドポイント&署名済みAWSクレデンシャル)が代入されます - 33-40行目 : MQTTクライアントでAWS IoT Websocketエンドポイントに署名済みAWSクレデンシャルを付与して接続します
"wss://xxxxxxxxxxxxxx.iot.ap-northeast-1.amazonaws.com/mqtt
Web API(API Gateway/Lambda)の実装
API Gatewayは、最近実装されたCustom Authorizationを使おうと思ったのですが、残念ながら今回のEndorseの仕様が対応していなかった *1ため、以前の記事の[API Gatewayの設定]と同じくクエリストリングsoracom_endorse_token
をイベントオブジェクトに渡るよう設定し、バックエンドで認証処理を行っています。
こちらもHTMLファイルからAjaxでアクセスするため、API GatewayでCORS設定を追加しましょう。
Lambda関数の実装を以下に示します。それなりにいろいろやっていますが、コメントの(X-X)
で追って見てみてください。
var request = require('request'); var aws = require('aws-sdk'); var jwt = require('jsonwebtoken'); var soracom = require('soracom'); var moment = require('moment'); var crypto = require('crypto-js'); // SORACOMの認証情報 var sora_operatorId = 'OP00XXXXXXXX'; var sora_authKeyId = 'keyId-XXXXXXXXXXXXXXXXXXX'; var sora_authKey = 'secret-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'; // SORACOM Endorseの検証用公開キーストア(固定) var sora_keystore = 'https://s3-ap-northeast-1.amazonaws.com/soracom-public-keys/'; // AWS IoTの情報 var aws_region = 'ap-northeast-1'; var aws_iot_endpoint = 'XXXXXXXXXXXXXX.iot.ap-northeast-1.amazonaws.com'; // Amazon STSでAssumeRoleするロールのARN var aws_role_arn = 'arn:aws:iam::XXXXXXXXXXXX:role/YOUR_ROLE_NAME'; console.log('Loading event'); exports.handler = function(event, context) { // トークンをデコード var decoded = jwt.decode(event.token, {complete: true}); // ヘッダに含まれるキー名からキーストアのURLを生成し、取得 request(sora_keystore + decoded.header.kid, function (err, response, body){ var pubkey = body; try { // トークンの検証が成功すればtryブロックを継続 ----------------------(2-1) jwt.verify(event.token, pubkey); // デコードしたペイロードからIMSIを取得 var imsi = decoded.payload['soracom-endorse-claim'].imsi; // SORACOM APIにアクセス var sora_obj = new soracom({ operatorId: sora_operatorId, authKeyId: sora_authKeyId, authKey: sora_authKey }); sora_obj.post('/auth', function(err, res, auth) { if (!err) { sora_obj.defaults(auth); sora_obj.get('/subscribers', function(err, res, body) { // SIM一覧から、IMSIを照合 ----------------------------------(2-2) var result = body.some(function(subscriber) { return subscriber.imsi == imsi; }) if(result) { // IMSIの照合が成功したら、AWSの一時クレデンシャルを取得 -----(2-3) var sts = new aws.STS(); var params = { RoleArn: aws_role_arn, RoleSessionName: 'nominator-temp' }; sts.assumeRole(params, function (err, data) { if (!err) { // 一時クレデンシャルを含むレスポンスを返送 --------------(2-4) var endpoint = createEndpoint( aws_region, aws_iot_endpoint, data.Credentials.AccessKeyId, data.Credentials.SecretAccessKey, data.Credentials.SessionToken ); context.succeed(endpoint); } else { console.log(err, err.stack); } }); }else { // IMSI照合に失敗した場合 context.fail('ERROR : Not authorized your device'); }; }); } }); } catch(err) { // トークンの検証に失敗した場合 console.log(err, err.stack); context.fail(err); } }); }; // AWS署名ユーティリティオブジェクト function SigV4Utils(){} SigV4Utils.sign = function(key, msg) { var hash = crypto.HmacSHA256(msg, key); return hash.toString(crypto.enc.Hex); }; SigV4Utils.sha256 = function(msg) { var hash = crypto.SHA256(msg); return hash.toString(crypto.enc.Hex); }; SigV4Utils.getSignatureKey = function(key, dateStamp, regionName, serviceName) { var kDate = crypto.HmacSHA256(dateStamp, 'AWS4' + key); var kRegion = crypto.HmacSHA256(regionName, kDate); var kService = crypto.HmacSHA256(serviceName, kRegion); var kSigning = crypto.HmacSHA256('aws4_request', kService); return kSigning; }; // MQTT over Websocket用エンドポイントの生成 function createEndpoint(region, awsIotEndpoint, accessKeyId, secretAccessKey, sessionToken) { var time = moment.utc(); var dateStamp = time.format('YYYYMMDD'); var amzdate = dateStamp + 'T' + time.format('HHmmss') + 'Z'; var service = 'iotdevicegateway'; var algorithm = 'AWS4-HMAC-SHA256'; var method = 'GET'; var canonicalUri = '/mqtt'; var host = awsIotEndpoint.toLowerCase(); var credentialScope = dateStamp + '/' + region + '/' + service + '/' + 'aws4_request'; var canonicalQuerystring = 'X-Amz-Algorithm=AWS4-HMAC-SHA256'; canonicalQuerystring += '&X-Amz-Credential=' + encodeURIComponent(accessKeyId + '/' + credentialScope); canonicalQuerystring += '&X-Amz-Date=' + amzdate; canonicalQuerystring += '&X-Amz-SignedHeaders=host'; var canonicalHeaders = 'host:' + host + '\n'; var payloadHash = SigV4Utils.sha256(''); var canonicalRequest = method + '\n' + canonicalUri + '\n' + canonicalQuerystring + '\n' + canonicalHeaders + '\nhost\n' + payloadHash; var stringToSign = algorithm + '\n' + amzdate + '\n' + credentialScope + '\n' + SigV4Utils.sha256(canonicalRequest); var signingKey = SigV4Utils.getSignatureKey(secretAccessKey, dateStamp, region, service); var signature = SigV4Utils.sign(signingKey, stringToSign); canonicalQuerystring += '&X-Amz-Signature=' + signature; var wssEndpoint = 'wss://' + host + canonicalUri + '?' + canonicalQuerystring + '&X-Amz-Security-Token=' + encodeURIComponent(sessionToken); return wssEndpoint; }
依存モジュールをnpm
でインストールし、zipで固めてデプロイしましょう。lambchopというツールが便利です。
$ npm install request aws-sdk jsonwebtoken soracom moment crypto-js $ vim index.js # lambchopのヘッダ(Lambdaの設定)を先頭に追加 $ chmod +x index.js $ ./index.js :(以下略)
これでおもむろにHTMLファイルをホストするURLにアクセスすると、ユーザーが認証情報を入力すること無くAjaxでEndorseの署名による認証とクレデンシャル取得が実行、MQTTクライアントでAWS IoTのMQTTブローカーに接続してくれます。
まとめ
ユーザーの認証情報をオフロードする方法の一つとして、SORACOM Endorseのトークンを利用する例をご紹介しました。 SORACOMのSIM単位で認証できるので、複数のSIMのIMSIをグループ化してグループチャットを作ったりすることもできそうですね!また、今回は実装しませんでしたがIMEI(デバイス固有のID)を送ることもできるので、IMEIのリストをWeb API側で持ち認証ロジックをいじれば、デバイスとSIMの縛りを強制することも難しくないと思います。
脚注
- Identity token sourceにクエリストリングが指定できない ↩